﻿using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
#if NET40
using System.Linq.Expressions;
#else
using Microsoft.Scripting.Ast;
#endif
using System.Reflection;

namespace CoNatural.Binders {
	/// <summary>
	/// Property mapping
	/// </summary>
	public class PropertyMapping : MappingBase {
		public bool CheckNullable { get; private set; }
		public IList<PropertyMapping> IndirectMappings { get; private set; }

		protected internal PropertyMapping(string source, string target, bool checkNullable, IList<PropertyMapping> indirectMappings) : base(source, target, null) {
			CheckNullable = checkNullable;
			IndirectMappings = indirectMappings;
		}
	}

	/// <summary>
	/// Creates binder delegates to bind objects by their public scalar property names and types.
	/// Source and Target types must be public.
	/// When a match for any given target property is not found in the source type, 
	/// the binder traverses all public reference properties (of public types) in
	/// the source until it finds a match or reaches the MAX_LEVEL = 3.
	/// </summary>
	public class PropertyBinderDelegateFactory : IBinderDelegateFactory {
		// Binding flags for source and target types
		internal static readonly BindingFlags SourceFlags = BindingFlags.GetProperty | BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy;
		internal static readonly BindingFlags TargetFlags = BindingFlags.SetProperty | BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy;

		// Max indirection level when traversing source type
		public const int MAX_LEVEL = 3;

		#region IBinderDelegateFactory Members

		public IBinder CreateBinder(object source, Type targetType) {
			var sourceType = source.GetType();
			var mappings = GetMappings(sourceType, targetType);
			var sourceParam = Expression.Parameter(typeof(object), "source");
			var targetParam = Expression.Parameter(typeof(object), "target");
			var binderBlock = GetBinderBlock(sourceType, targetType, sourceParam, targetParam, mappings);
			var mapperBlock = GetMapperBlock(sourceType, sourceParam, mappings);
			return new BinderBase(
				Expression.Lambda<BinderDelegate>(binderBlock, sourceParam, targetParam).Compile(),
				Expression.Lambda<MapperDelegate>(mapperBlock, sourceParam).Compile()
			);
		}

		#endregion

		#region Binding

		/// <summary>
		/// Generates mappings from source type to target type.
		/// </summary>
		/// <param name="sourceType">The source type.</param>
		/// <param name="targetType">The target type.</param>
		/// <returns>The mappings.</returns>
		private IList<PropertyMapping> GetMappings(Type sourceType, Type targetType) {
			if(!sourceType.IsVisible)
				throw new Exception("Source type " + sourceType.FullName + " is not visible outside assembly.");

			if(!targetType.IsVisible)
				throw new Exception("Target type " + targetType.FullName + " is not visible outside assembly.");

			var targetProperties = GetBindableProperties(targetType);
			return GetMappings(targetProperties, sourceType, string.Empty, string.Empty, 0);
		}

		private IDictionary<string, PropertyInfo> GetBindableProperties(Type type) {
			var properties = type.GetProperties(PropertyBinderDelegateFactory.TargetFlags).ToDictionary<PropertyInfo, string>(p => p.Name);
			if(properties == null || properties.Count == 0)
				throw new Exception(string.Concat("No bindable properties found for type ", type.FullName));
			return properties;
		}

		private IList<PropertyMapping> GetMappings(IDictionary<string, PropertyInfo> targetProperties, Type sourceType, string sourcePath, string targetPath, int level) {
			var mappings = new List<PropertyMapping>();
			PropertyInfo targetProperty;
			PropertyMapping mapping;

			foreach (var sourceProperty in sourceType.GetProperties(SourceFlags)) {
				mapping = null;
				if (targetProperties.TryGetValue(sourceProperty.Name, out targetProperty)) {
					mapping = GetMapping(sourceProperty, targetProperty, sourcePath, targetPath, level);
					if (mapping != null) {
						mappings.Add(mapping);

						// stop when all properties have been mapped
						targetProperties.Remove(targetProperty.Name);
						if (targetProperties.Count == 0)
							break;
					}
				}

				// traverse source type until max level is reached
				if (mapping == null && level < MAX_LEVEL) {
					// traverse public references that are not strings
					if (sourceProperty.PropertyType.IsPublic && sourceProperty.PropertyType.IsClass && sourceProperty.PropertyType != typeof(string)) {
						// don't traverse collections or arrays
						if (!sourceProperty.PropertyType.IsArray && sourceProperty.PropertyType.GetInterface(typeof(ICollection).FullName) == null) {
							var indirectPath = string.Concat(string.IsNullOrEmpty(sourcePath) ? string.Empty : string.Concat(sourcePath, "."), sourceProperty.Name);
							mapping = new PropertyMapping(indirectPath, sourceProperty.Name, false, GetMappings(targetProperties, sourceProperty.PropertyType, indirectPath, targetPath, level + 1));
							mappings.Add(mapping);
						}
					}

					// stop when all properties have been mapped
					if (targetProperties.Count == 0)
						break;
				}
			}
			return mappings;
		}

		/// <summary>
		/// Gets mapping from source property to target property,
		/// or null if properties are not bindable.
		/// </summary>
		/// <param name="sourceProperty">The source property.</param>
		/// <param name="targetProperty">The target property.</param>
		/// <param name="sourcePath">The indirect path to the source property.</param>
		/// <param name="targetPath">The path to the target property.</param>
		/// <param name="level">The indirect level of the source property.</param>
		/// <returns>The mapping or null if not bindable.</returns>
		protected internal virtual PropertyMapping GetMapping(PropertyInfo sourceProperty, PropertyInfo targetProperty, string sourcePath, string targetPath, int level) {
			var sourceType = sourceProperty.PropertyType;
			var targetType = targetProperty.PropertyType;
			var sourceIsNullable = false;
			var targetIsNullable = false;

			// we need to check for nulls if type nullabilities don't match and source is nullable
			if (sourceType.IsNullable()) {
				sourceType = sourceType.GetGenericArguments()[0];
				sourceIsNullable = true;
			}
			if (targetType.IsNullable()) {
				targetType = targetType.GetGenericArguments()[0];
				targetIsNullable = true;
			}

			// properties are bindable if types match
			if (sourceType == targetType)
				return new PropertyMapping(
					string.Concat(string.IsNullOrEmpty(sourcePath) ? string.Empty : string.Concat(sourcePath, "."), sourceProperty.Name),
					string.Concat(string.IsNullOrEmpty(targetPath) ? string.Empty : string.Concat(targetPath, "."), targetProperty.Name),
					(sourceIsNullable != targetIsNullable && sourceIsNullable),
					null
				);

			// not bindable
			return null;
		}

		#endregion Binding

		#region Expressions

		private BlockExpression GetBinderBlock(Type sourceType, Type targetType, ParameterExpression sourceParam, ParameterExpression targetParam, IList<PropertyMapping> mappings) {
			var end = Expression.Label(typeof(object), "end");

			if (mappings.Count > 0) {
				var source = Expression.Variable(sourceType, "s");
				var target = Expression.Variable(targetType, "t");

				return Expression.Block(
					new[] { source, target },
					Expression.Assign(source, Expression.Convert(sourceParam, sourceType)),
					Expression.Assign(target, Expression.Convert(targetParam, targetType)),
					Expression.IfThen(Expression.Equal(target, Expression.Constant(null)), Expression.Assign(target, Expression.New(targetType))),
					Expression.Block(GetBinderExpressions(mappings, source, target, false)),
					Expression.Label(end, target)
				);
			}
			else
				return Expression.Block(
					Expression.Label(end, targetParam)
				);
		}

		private IEnumerable<Expression> GetBinderExpressions(IList<PropertyMapping> mappings, Expression source, Expression target, bool indirect) {
			foreach (var mapping in mappings) {
				if (mapping.IndirectMappings == null) {
					var assign = Expression.Assign(Expression.Property(target, mapping.Target), Expression.Property(source, mapping.Target));
					if (mapping.CheckNullable) {
						// check for nulls if type nullabilities don't match and source is nullable
						var assignvalue = Expression.Assign(Expression.Property(target, mapping.Target), Expression.Property(Expression.Property(source, mapping.Target), "Value"));
						yield return Expression.IfThenElse(Expression.Property(source, "HasValue"), assignvalue, assign);
					}
					else
						yield return assign;
				}
				else {
					var isource = Expression.Property(source, mapping.Target);
					yield return Expression.IfThen(Expression.NotEqual(source, Expression.Constant(null)), 
						Expression.Block(GetBinderExpressions(mapping.IndirectMappings, isource, target, true)));
				}
			}
		}

		private BlockExpression GetMapperBlock(Type sourceType, ParameterExpression sourceParam, IList<PropertyMapping> mappings) {
			var maplist = Expression.Variable(typeof(List<IMapping>), "mappings");
			var end = Expression.Label(typeof(List<IMapping>), "end");

			if (mappings.Count > 0) {
				var source = Expression.Variable(sourceType, "s");

				return Expression.Block(
					new[] { source, maplist },
					Expression.Assign(source, Expression.Convert(sourceParam, sourceType)),
					Expression.Assign(maplist, Expression.New(typeof(List<IMapping>))),
					Expression.Block(GetMapperExpressions(mappings, source, maplist, false)),
					Expression.Label(end, maplist)
				);
			}
			else
				return Expression.Block(
					new[] { maplist },
					Expression.Assign(maplist, Expression.New(typeof(List<IMapping>))),
					Expression.Label(end, maplist)
				);
		}

		private IEnumerable<Expression> GetMapperExpressions(IList<PropertyMapping> mappings, Expression source, Expression maplist, bool indirect) {
			foreach (var mapping in mappings) {
				if (mapping.IndirectMappings == null) {
					// create expression to construct new IMapping from mapping
					var constructor = typeof(MappingBase).GetConstructor(new[] { typeof(string), typeof(string), typeof(object) });
					var map = Expression.New(
						constructor, 
						Expression.Constant(mapping.Source), 
						Expression.Constant(mapping.Target), 
						indirect ? source : Expression.Constant(null)
					);
					yield return Expression.Call(maplist, typeof(List<IMapping>).GetMethod("Add"), map);
				}
				else {
					var isource = Expression.Property(source, mapping.Target);
					yield return Expression.IfThen(Expression.NotEqual(source, Expression.Constant(null)),
						Expression.Block(GetMapperExpressions(mapping.IndirectMappings, isource, maplist, true)));
				}
			}
		}

		#endregion Expressions
	}
}
